perf: Optimize geolocation with local MaxMind database and caching#361
perf: Optimize geolocation with local MaxMind database and caching#361superdav42 merged 6 commits intomainfrom
Conversation
Performance improvements: - Use WooCommerce's bundled MaxMind database reader (eliminates external API calls) - Add in-memory static cache for same-request lookups - Add object cache integration for cross-request caching - Improve IP detection for CloudFlare, proxies, and load balancers - Reuse MaxMind Reader instance across lookups - Fall back gracefully when database is unavailable This eliminates the 1+ second delays caused by external HTTP calls to geolocation APIs (ipinfo.io, ip-api.com) on every page load. Key changes: - get_ip_address(): Now checks CF-Connecting-IP, True-Client-IP headers - geolocate_ip(): Uses multi-layer caching (memory -> object cache -> db) - geolocate_via_db(): Reuses MaxMind Reader, uses WooCommerce's database - get_external_ip_address(): Deprecated, no longer makes external calls - Added clear_cache() method for cache invalidation
📝 WalkthroughWalkthroughReplaces external geolocation calls with a local MaxMind DB approach: adds Reader lifecycle (init/close_reader), per-request and persistent caching, header-based IP extraction/validation, geolocate_ip flow with DB lookup/update scaffolding, cache controls/constants, and comprehensive unit tests. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client/Request
participant Geoloc as Geolocation
participant Mem as In‑memory Cache
participant ObjCache as Object Cache
participant Reader as MaxMind DB Reader
Client->>Geoloc: geolocate_ip(request_ip)
activate Geoloc
Geoloc->>Geoloc: validate_ip / get_country_from_headers
Geoloc->>Mem: check per-request cache
alt memory hit
Mem-->>Geoloc: return cached result
else memory miss
Geoloc->>ObjCache: check persistent cache (CACHE_GROUP)
alt persistent hit
ObjCache-->>Geoloc: return cached result
else persistent miss
Geoloc->>Reader: init() / query local MaxMind DB
activate Reader
Reader-->>Geoloc: country/state
deactivate Reader
Geoloc->>ObjCache: store result (TTL)
end
Geoloc->>Mem: store in-memory result
end
deactivate Geoloc
Geoloc-->>Client: return { ip, country, state }
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
inc/class-geolocation.php (1)
399-424: Remove or use these unused private methods.Static analysis correctly identifies that
supports_geolite2()andis_geolocation_enabled()are never called. Since they're private, they cannot be used externally.Options:
- Remove them if not needed
- Use them where appropriate (e.g.,
supports_geolite2()could guard the DB lookup ingeolocate_via_db())- If intended for future use, add a
@todoannotation explaining the plan♻️ Option 1: Remove unused methods
- /** - * Check if server supports MaxMind GeoLite2 Reader. - * - * `@return` bool - */ - private static function supports_geolite2(): bool - { - // Check if WooCommerce's MaxMind reader is available - if (class_exists('MaxMind\Db\Reader')) { - return true; - } - - // Check if WooCommerce is installed with the reader - if (defined('WC_ABSPATH')) { - $reader_path = WC_ABSPATH . 'vendor/maxmind-db/reader/src/MaxMind/Db/Reader.php'; - return file_exists($reader_path); - } - - return false; - } - - /** - * Check if geolocation is enabled. - * - * `@param` string $current_settings Current geolocation settings. - * `@return` bool - */ - private static function is_geolocation_enabled(string $current_settings): bool - { - return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true); - }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@inc/class-geolocation.php` around lines 399 - 424, These two private methods, supports_geolite2() and is_geolocation_enabled(), are never called; either remove them or wire them into existing logic: if you don't need them, delete both methods; otherwise call supports_geolite2() from geolocate_via_db() to guard MaxMind DB lookups instead of duplicating checks, and replace any ad-hoc geolocation setting checks with is_geolocation_enabled($current_settings) where the code currently tests for 'geolocation'/'geolocation_ajax'; if you intend to keep them for future work, add a `@todo` explaining planned usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@inc/class-geolocation.php`:
- Around line 301-313: The code currently requires the MaxMind Reader file
($wc_reader) before loading the Composer autoloader ($autoload), which can cause
missing-dependency errors; change the order so that when WC_ABSPATH vendor files
exist you first check for and require_once the autoload file ($autoload) and
then require_once the Reader.php ($wc_reader) if still needed, preserving the
existing checks around defined('WC_ABSPATH') and file_exists and keeping the
class_exists('MaxMind\\Db\\Reader') guard.
- Around line 28-41: Move the opening brace for the class Geolocation to the
same line as the class declaration, replace space indentation with tabs inside
the class docblocks/members (affecting the docblock and constant declarations
around CACHE_GROUP and CACHE_TTL), and replace the hardcoded TTL value 86400
with the DAY_IN_SECONDS constant (i.e., set CACHE_TTL = DAY_IN_SECONDS) so
formatting and readability conform to the pipeline rules.
- Around line 242-246: The 'state' field is left empty in the $result array
returned by geolocate_via_db(); update geolocate_via_db() to extract the first
MaxMind subdivision ISO code when present (check
isset($data['subdivisions'][0]['iso_code'])), set $state =
strtoupper(sanitize_text_field(...)) and use that value for the 'state' key in
the $result array, falling back to an empty string if not available.
---
Nitpick comments:
In `@inc/class-geolocation.php`:
- Around line 399-424: These two private methods, supports_geolite2() and
is_geolocation_enabled(), are never called; either remove them or wire them into
existing logic: if you don't need them, delete both methods; otherwise call
supports_geolite2() from geolocate_via_db() to guard MaxMind DB lookups instead
of duplicating checks, and replace any ad-hoc geolocation setting checks with
is_geolocation_enabled($current_settings) where the code currently tests for
'geolocation'/'geolocation_ajax'; if you intend to keep them for future work,
add a `@todo` explaining planned usage.
inc/class-geolocation.php
Outdated
| $result = [ | ||
| 'ip' => $ip_address, | ||
| 'country' => is_string($country_code) ? $country_code : '', | ||
| 'state' => '', | ||
| ]; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check how state data is used across the codebase
rg -n "ip_state|'state'" --type php -C 2 | head -80Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 4540
🏁 Script executed:
# Find class-customer.php and search for ip_state usage
fd -e php | xargs rg -l "ip_state" | head -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 189
🏁 Script executed:
# Search more broadly for ip_state in the codebase
rg "ip_state" -n --type php | head -40Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 342
🏁 Script executed:
# Find the geolocate_via_db method implementation
rg "geolocate_via_db" -n --type php -A 30 | head -80Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 4243
🏁 Script executed:
# Check for MaxMind API/database handling and subdivision data
rg "subdivisions|iso_code" -n --type php | head -30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 290
🏁 Script executed:
rg "private static function geolocate_via_db" -A 50 --type php inc/class-geolocation.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 1955
🏁 Script executed:
# Also check the full MaxMind usage to see what data is available
rg "self::\$reader" -B 3 -A 15 --type php inc/class-geolocation.php | head -80Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
State field is always empty and should be populated from MaxMind subdivision data.
The 'state' field is hardcoded to an empty string, but it's actively used throughout the codebase:
- Stored in customer metadata (
class-customer.phpline 558) - Displayed in the admin interface (
class-customer-edit-admin-page.phpline 1264) - Used for filtering by state (
countries.phpline 492)
The geolocate_via_db() method retrieves the full MaxMind data but only extracts the country code. Extract the subdivision/state code by accessing $data['subdivisions'][0]['iso_code'] when available and return it in the result array:
$state = '';
if (isset($data['subdivisions'][0]['iso_code'])) {
$state = strtoupper(\sanitize_text_field($data['subdivisions'][0]['iso_code']));
}Then populate the 'state' field in the result array instead of hardcoding an empty string.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/class-geolocation.php` around lines 242 - 246, The 'state' field is left
empty in the $result array returned by geolocate_via_db(); update
geolocate_via_db() to extract the first MaxMind subdivision ISO code when
present (check isset($data['subdivisions'][0]['iso_code'])), set $state =
strtoupper(sanitize_text_field(...)) and use that value for the 'state' key in
the $result array, falling back to an empty string if not available.
…erce dependency - Add maxmind-db/reader ^1.12 to composer dependencies - Update Geolocation class to use Composer autoloader instead of WooCommerce - Remove manual loading of WC_ABSPATH/vendor/maxmind-db/reader - Simplify get_local_database_path() to only check WP Ultimo path - Update supports_geolite2() to rely on Composer autoloader - Add comprehensive test suite (35+ tests) for Geolocation class BREAKING CHANGE: Geolocation no longer checks WooCommerce for MaxMind database
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
inc/class-geolocation.php (1)
228-232:⚠️ Potential issue | 🟠 Major
stateremains permanently empty; this regresses existing consumers.
geolocate_ip()hardcodes'state' => ''andgeolocate_via_db()only returns country. Callers ininc/models/class-customer.phpand checkout paths expectstateto be meaningful, so this now stores/displays empty state data.Also applies to: 274-291
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@inc/class-geolocation.php` around lines 228 - 232, geolocate_ip() currently hardcodes 'state' => '' (and geolocate_via_db() only returns country), which breaks callers expecting a meaningful state; update both functions so they extract and return a state/region value from the geolocation provider response (e.g., use the region/region_code/ subdivision field from the MaxMind/DB record or provider response), fall back to an empty string only if no state/region is available, and ensure the returned array keys remain 'ip','country','state' so callers in class-customer and checkout continue to work; update geolocate_via_db() to include the same 'state' extraction logic (see geolocate_ip(), geolocate_via_db()) and any local variables like $record, $country_code to locate where to read the region.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@inc/class-geolocation.php`:
- Around line 105-107: The early-return guard in the method using
self::$cached_ip is inverted: it currently returns when self::$cached_ip is
null, causing a null to be returned where a string is expected; change the check
to return the cached value only when self::$cached_ip is NOT null (e.g., if
(null !== self::$cached_ip) return self::$cached_ip) so that the first call
computes and sets the cached string and subsequent calls return that string;
update the method that references self::$cached_ip to preserve the declared
string return type.
- Around line 111-129: The current header-parsing in class-geolocation.php
trusts HTTP_CF_CONNECTING_IP / HTTP_TRUE_CLIENT_IP / HTTP_X_REAL_IP /
HTTP_X_FORWARDED_FOR unconditionally; change it to only accept those forwarded
headers when the immediate source
(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) or similar) is a
known/trusted proxy IP. Implement a trusted-proxy check (e.g., a whitelist array
or a helper like is_trusted_proxy()) before reading HTTP_CF_CONNECTING_IP,
HTTP_TRUE_CLIENT_IP, HTTP_X_REAL_IP or parsing HTTP_X_FORWARDED_FOR, and if the
source is not trusted fall back to REMOTE_ADDR; continue to validate the chosen
candidate with self::validate_ip().
In `@tests/WP_Ultimo/Geolocation_Test.php`:
- Line 210: Replace the deprecated PHPUnit assertion call: change the use of
assertStringContains to assertStringContainsString in the Geolocation_Test class
(the test method containing the assertion referencing $path) so it matches
PHPUnit 9's API; update the assertion invocation where
"$this->assertStringContains('uploads', $path)" appears to call
"$this->assertStringContainsString('uploads', $path)" instead.
---
Duplicate comments:
In `@inc/class-geolocation.php`:
- Around line 228-232: geolocate_ip() currently hardcodes 'state' => '' (and
geolocate_via_db() only returns country), which breaks callers expecting a
meaningful state; update both functions so they extract and return a
state/region value from the geolocation provider response (e.g., use the
region/region_code/ subdivision field from the MaxMind/DB record or provider
response), fall back to an empty string only if no state/region is available,
and ensure the returned array keys remain 'ip','country','state' so callers in
class-customer and checkout continue to work; update geolocate_via_db() to
include the same 'state' extraction logic (see geolocate_ip(),
geolocate_via_db()) and any local variables like $record, $country_code to
locate where to read the region.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 49bb8fa2-5ccf-4db4-b784-64aece73e4c3
⛔ Files ignored due to path filters (1)
composer.lockis excluded by!**/*.lock
📒 Files selected for processing (3)
composer.jsoninc/class-geolocation.phptests/WP_Ultimo/Geolocation_Test.php
| // Cloudflare (most trusted when using CF) | ||
| if (! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { | ||
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP'])); | ||
| } elseif (! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) { | ||
| // Cloudflare Enterprise / Akamai | ||
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP'])); | ||
| } elseif (! empty($_SERVER['HTTP_X_REAL_IP'])) { | ||
| // Nginx proxy | ||
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP'])); | ||
| } elseif (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { | ||
| // Standard proxy header (take first IP - the client) | ||
| $forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])); | ||
| // X-Forwarded-For: client, proxy1, proxy2 | ||
| $ips = array_map('trim', explode(',', $forwarded)); | ||
| $ip = self::validate_ip($ips[0]); | ||
| } elseif (! empty($_SERVER['REMOTE_ADDR'])) { | ||
| // Direct connection | ||
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])); | ||
| } |
There was a problem hiding this comment.
Don’t trust forwarded headers without a trusted-proxy gate.
This block accepts HTTP_CF_CONNECTING_IP / HTTP_X_FORWARDED_FOR unconditionally. A direct client can spoof these headers and bypass geo-based restrictions.
🔐 Safer direction
- if (! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
+ $remote_addr = ! empty($_SERVER['REMOTE_ADDR']) ? \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
+ $trust_proxy_headers = (bool) \apply_filters('wu_geolocation_trust_proxy_headers', false, $remote_addr);
+
+ if ($trust_proxy_headers && ! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
- } elseif (! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
+ } elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) {
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP']));
- } elseif (! empty($_SERVER['HTTP_X_REAL_IP'])) {
+ } elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_X_REAL_IP'])) {
$ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP']));
- } elseif (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+ } elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
...
} elseif (! empty($_SERVER['REMOTE_ADDR'])) {
$ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR']));
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Cloudflare (most trusted when using CF) | |
| if (! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { | |
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP'])); | |
| } elseif (! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) { | |
| // Cloudflare Enterprise / Akamai | |
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP'])); | |
| } elseif (! empty($_SERVER['HTTP_X_REAL_IP'])) { | |
| // Nginx proxy | |
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP'])); | |
| } elseif (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { | |
| // Standard proxy header (take first IP - the client) | |
| $forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])); | |
| // X-Forwarded-For: client, proxy1, proxy2 | |
| $ips = array_map('trim', explode(',', $forwarded)); | |
| $ip = self::validate_ip($ips[0]); | |
| } elseif (! empty($_SERVER['REMOTE_ADDR'])) { | |
| // Direct connection | |
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])); | |
| } | |
| $remote_addr = ! empty($_SERVER['REMOTE_ADDR']) ? \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; | |
| $trust_proxy_headers = (bool) \apply_filters('wu_geolocation_trust_proxy_headers', false, $remote_addr); | |
| // Cloudflare (most trusted when using CF) | |
| if ($trust_proxy_headers && ! empty($_SERVER['HTTP_CF_CONNECTING_IP'])) { | |
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP'])); | |
| } elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_TRUE_CLIENT_IP'])) { | |
| // Cloudflare Enterprise / Akamai | |
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_TRUE_CLIENT_IP'])); | |
| } elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_X_REAL_IP'])) { | |
| // Nginx proxy | |
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_REAL_IP'])); | |
| } elseif ($trust_proxy_headers && ! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { | |
| // Standard proxy header (take first IP - the client) | |
| $forwarded = \sanitize_text_field(\wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])); | |
| // X-Forwarded-For: client, proxy1, proxy2 | |
| $ips = array_map('trim', explode(',', $forwarded)); | |
| $ip = self::validate_ip($ips[0]); | |
| } elseif (! empty($_SERVER['REMOTE_ADDR'])) { | |
| // Direct connection | |
| $ip = \sanitize_text_field(\wp_unslash($_SERVER['REMOTE_ADDR'])); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/class-geolocation.php` around lines 111 - 129, The current header-parsing
in class-geolocation.php trusts HTTP_CF_CONNECTING_IP / HTTP_TRUE_CLIENT_IP /
HTTP_X_REAL_IP / HTTP_X_FORWARDED_FOR unconditionally; change it to only accept
those forwarded headers when the immediate source
(sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) or similar) is a
known/trusted proxy IP. Implement a trusted-proxy check (e.g., a whitelist array
or a helper like is_trusted_proxy()) before reading HTTP_CF_CONNECTING_IP,
HTTP_TRUE_CLIENT_IP, HTTP_X_REAL_IP or parsing HTTP_X_FORWARDED_FOR, and if the
source is not trusted fall back to REMOTE_ADDR; continue to validate the chosen
candidate with self::validate_ip().
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
… assertion method in tests
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
…ting other test suites
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
inc/class-geolocation.php (1)
343-356: Remove or expose unused private methods.Static analysis flags
supports_geolite2()andis_geolocation_enabled()as unused. If these are intended for future use or plugin extensibility, consider making them public. Otherwise, remove them to reduce dead code.♻️ Option 1: Remove if not needed
- /** - * Check if server supports MaxMind GeoLite2 Reader. - * - * `@return` bool - */ - private static function supports_geolite2(): bool { - // Check if MaxMind reader is available via Composer autoloader - return class_exists('MaxMind\Db\Reader'); - } - - /** - * Check if geolocation is enabled. - * - * `@param` string $current_settings Current geolocation settings. - * `@return` bool - */ - private static function is_geolocation_enabled(string $current_settings): bool { - return in_array($current_settings, ['geolocation', 'geolocation_ajax'], true); - }♻️ Option 2: Make public for extensibility
- private static function supports_geolite2(): bool { + public static function supports_geolite2(): bool { ... - private static function is_geolocation_enabled(string $current_settings): bool { + public static function is_geolocation_enabled(string $current_settings): bool {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@inc/class-geolocation.php` around lines 343 - 356, The two private static methods supports_geolite2() and is_geolocation_enabled() are flagged as unused; either remove both methods to eliminate dead code, or change their visibility to public static (and update their docblocks) so they are intentionally exposed for external use/extension; if choosing to expose, ensure no internal call sites rely on them remaining private and add a unit test or inline comment stating they are part of the public API for clarity.tests/WP_Ultimo/Geolocation_Test.php (2)
432-442: Consider verifying cache hit via reflection.The test confirms the results are equal, but doesn't verify that the second call actually came from the in-memory cache. You could inspect
$memory_cachevia reflection to confirm it contains the IP after the first call.💡 Enhanced test suggestion
public function test_geolocate_ip_caching() { $ip = '127.0.0.1'; // First call $result1 = Geolocation::geolocate_ip($ip); // Verify cache was populated $reflection = new \ReflectionClass(Geolocation::class); $memory_cache = $reflection->getProperty('memory_cache'); if (PHP_VERSION_ID < 80100) { $memory_cache->setAccessible(true); } $cache_value = $memory_cache->getValue(); $this->assertArrayHasKey($ip, $cache_value); // Second call should return from cache $result2 = Geolocation::geolocate_ip($ip); $this->assertEquals($result1, $result2); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/WP_Ultimo/Geolocation_Test.php` around lines 432 - 442, Update test_geolocate_ip_caching to assert that the in-memory cache was populated after the first call: after calling Geolocation::geolocate_ip($ip) use ReflectionClass on Geolocation to get the memory_cache property (handle accessibility for older PHP versions by calling setAccessible(true) when needed), read the property value and assert the IP key exists in that array/structure, then proceed to call Geolocation::geolocate_ip($ip) again and assert results equal; reference Geolocation::geolocate_ip and the memory_cache property to locate where to add the reflection check.
234-245: Consider adding a negative test forsupports_geolite2()when the class doesn't exist.The current test only verifies the happy path (Composer package installed). For completeness, you could mock or test the scenario where
MaxMind\Db\Readeris not available, though this is difficult without unloading the class.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/WP_Ultimo/Geolocation_Test.php` around lines 234 - 245, Add a negative test method (e.g., test_supports_geolite2_returns_false_when_class_missing) that verifies Geolocation::supports_geolite2() returns false when MaxMind\Db\Reader is unavailable: run it in isolation (use `@runInSeparateProcess` or spawn a separate PHP process) so the MaxMind class cannot be autoloaded, use ReflectionClass(Geolocation::class) to get the supports_geolite2 method (as in the existing test) and invoke it (setAccessible when PHP_VERSION_ID < 80100) and assertFalse on the result; if your environment supports extensions, you can also use uopz or runkit to undefine the MaxMind class before calling supports_geolite2 to simulate the missing-class scenario.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@inc/class-geolocation.php`:
- Around line 69-72: Call Geolocation::init() early in the request lifecycle so
the shutdown handler that invokes Geolocation::close_reader() is registered and
the lazily-created MaxMind Reader used by Geolocation::geolocate_ip() and
Geolocation::get_ip_address() is closed at request end; add a single bootstrap
hook (for example on wp_loaded or in the plugin main bootstrap) that calls
Geolocation::init() during initialization.
---
Nitpick comments:
In `@inc/class-geolocation.php`:
- Around line 343-356: The two private static methods supports_geolite2() and
is_geolocation_enabled() are flagged as unused; either remove both methods to
eliminate dead code, or change their visibility to public static (and update
their docblocks) so they are intentionally exposed for external use/extension;
if choosing to expose, ensure no internal call sites rely on them remaining
private and add a unit test or inline comment stating they are part of the
public API for clarity.
In `@tests/WP_Ultimo/Geolocation_Test.php`:
- Around line 432-442: Update test_geolocate_ip_caching to assert that the
in-memory cache was populated after the first call: after calling
Geolocation::geolocate_ip($ip) use ReflectionClass on Geolocation to get the
memory_cache property (handle accessibility for older PHP versions by calling
setAccessible(true) when needed), read the property value and assert the IP key
exists in that array/structure, then proceed to call
Geolocation::geolocate_ip($ip) again and assert results equal; reference
Geolocation::geolocate_ip and the memory_cache property to locate where to add
the reflection check.
- Around line 234-245: Add a negative test method (e.g.,
test_supports_geolite2_returns_false_when_class_missing) that verifies
Geolocation::supports_geolite2() returns false when MaxMind\Db\Reader is
unavailable: run it in isolation (use `@runInSeparateProcess` or spawn a separate
PHP process) so the MaxMind class cannot be autoloaded, use
ReflectionClass(Geolocation::class) to get the supports_geolite2 method (as in
the existing test) and invoke it (setAccessible when PHP_VERSION_ID < 80100) and
assertFalse on the result; if your environment supports extensions, you can also
use uopz or runkit to undefine the MaxMind class before calling
supports_geolite2 to simulate the missing-class scenario.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 158fcdfa-f4eb-4eff-bb55-750656070488
📒 Files selected for processing (2)
inc/class-geolocation.phptests/WP_Ultimo/Geolocation_Test.php
| public static function init(): void { | ||
| // Register shutdown handler to close MaxMind reader | ||
| \register_shutdown_function([self::class, 'close_reader']); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for any calls to Geolocation::init() in the codebase
rg -n "Geolocation::init\(\)" --type phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
#!/bin/bash
# Get the full class-geolocation.php file to understand the design
wc -l inc/class-geolocation.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 110
🏁 Script executed:
#!/bin/bash
# Read the class definition and constructor
cat -n inc/class-geolocation.php | head -100Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 3136
🏁 Script executed:
#!/bin/bash
# Search for any wp_hook registrations or filters that might initialize the class
rg -n "add_action|add_filter|do_action|do_filter" inc/class-geolocation.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
#!/bin/bash
# Check how Geolocation class is used in the codebase
rg -n "Geolocation::" --type php -A 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 9379
🏁 Script executed:
#!/bin/bash
# Look for constructor and get_ip_address method
cat -n inc/class-geolocation.php | sed -n '100,180p'Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 3114
🏁 Script executed:
#!/bin/bash
# Search for the get_ip_address method and how reader is initialized
rg -n "get_ip_address|function get_reader|reader =" inc/class-geolocation.php -A 3Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 985
🏁 Script executed:
#!/bin/bash
# Check if there's any bootstrap or plugin initialization file that might call init()
rg -n "Geolocation::init" . --type phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
#!/bin/bash
# Get the geolocate_ip method to see how reader is initialized
cat -n inc/class-geolocation.php | sed -n '178,300p'Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 4794
🏁 Script executed:
#!/bin/bash
# Check if there's any automatic PHP cleanup or if close_reader is called elsewhere
rg -n "close_reader|__destruct" inc/class-geolocation.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 195
🏁 Script executed:
#!/bin/bash
# Check if there are any bootstrap/loader files that might initialize
find inc -name "*loader*" -o -name "*bootstrap*" | head -5Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 182
🏁 Script executed:
#!/bin/bash
# Check the main autoloader
cat -n inc/class-autoloader.php | head -50Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1219
🏁 Script executed:
#!/bin/bash
# Check the loaders directory
ls -la inc/loaders/Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 266
🏁 Script executed:
#!/bin/bash
# Check for any wp_loaded or init hooks that might call Geolocation
rg -n "add_action.*wp_loaded|add_action.*init" inc/ --type php | head -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2014
🏁 Script executed:
#!/bin/bash
# Check the development/bootstrap file
cat -n inc/development/bootstrap.php | head -80Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1586
init() must be called to register the shutdown handler — MaxMind reader won't close without it.
The init() method registers close_reader() as a shutdown function (line 71), but Geolocation::init() is never invoked anywhere in the codebase. Without this, the MaxMind Reader instance will not be properly closed when the request ends, causing the file handle to remain open and leak resources.
All production usage (checkout, customer model, settings, etc.) directly calls geolocate_ip() or get_ip_address() without initialization. The reader is lazily instantiated on-demand (line 285), but without the shutdown handler registered, cleanup never occurs.
Add a call to Geolocation::init() in an early hook like wp_loaded or in the main plugin bootstrap to ensure the shutdown handler is registered.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@inc/class-geolocation.php` around lines 69 - 72, Call Geolocation::init()
early in the request lifecycle so the shutdown handler that invokes
Geolocation::close_reader() is registered and the lazily-created MaxMind Reader
used by Geolocation::geolocate_ip() and Geolocation::get_ip_address() is closed
at request end; add a single bootstrap hook (for example on wp_loaded or in the
plugin main bootstrap) that calls Geolocation::init() during initialization.
Summary
This PR dramatically improves geolocation performance by eliminating external HTTP calls that were causing 1+ second delays on every page load.
Problem
The existing geolocation implementation made external HTTP calls to services like
ipinfo.ioandip-api.comon every request. SPX profiling showed:WP_Ultimo\Geolocation::geolocate_ip- 1,117ms (2 external API calls)WP_Ultimo\Geolocation::get_external_ip_address- 645ms (external IP lookup)Solution
1. Use Local MaxMind Database
2. Multi-Layer Caching
3. Improved IP Detection
Headers are now checked in order of trust:
CF-Connecting-IP(Cloudflare)True-Client-IP(Cloudflare Enterprise / Akamai)X-Real-IP(Nginx proxy)X-Forwarded-For(Standard proxy - first IP only)REMOTE_ADDR(Direct connection)4. Performance Optimizations
HTTP_CF_IPCOUNTRY) is used when availableBreaking Changes
get_external_ip_address()no longer makes external API calls (kept for backwards compatibility)wu_geolocation_ip_lookup_apisfilter returning non-empty array)Testing
Performance Impact
Summary by CodeRabbit
New Features
Performance
Reliability
Bug Fixes
Tests